// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import * as Common from '../common/common.js';  // eslint-disable-line no-unused-vars
import * as ProtocolClientModule from '../protocol_client/protocol_client.js';
import * as Workspace from '../workspace/workspace.js';

/**
 * @fileoverview using private properties isn't a Closure violation in tests.
 * @suppress {accessControls}
 */

/* eslint-disable no-console */

/**
 * @return {boolean}
 */
export function isDebugTest() {
  return !self.testRunner || !!Root.Runtime.queryParam('debugFrontend');
}

/**
 * This monkey patches console functions in DevTools context so the console
 * messages are shown in the right places, instead of having all of the console
 * messages printed at the top of the test expectation file (default behavior).
 */
export function _printDevToolsConsole() {
  if (isDebugTest()) {
    return;
  }
  console.log = (...args) => {
    addResult(`log: ${args}`);
  };
  console.error = (...args) => {
    addResult(`error: ${args}`);
  };
  console.info = (...args) => {
    addResult(`info: ${args}`);
  };
  console.assert = (assertionCondition, ...args) => {
    if (!assertionCondition) {
      addResult(`ASSERTION FAILURE: ${args.join(' ')}`);
    }
  };
}

self['onerror'] = (message, source, lineno, colno, error) => {
  addResult('TEST ENDED IN ERROR: ' + error.stack);
  completeTest();
};
/** @suppressGlobalPropertiesCheck @suppress {checkTypes} */
(() => {
  self.addEventListener('unhandledrejection', event => {
    addResult(`PROMISE FAILURE: ${event.reason.stack}`);
    completeTest();
  });
})();
_printDevToolsConsole();

// TODO(crbug.com/1032477): Re-enable once test timeouts are handled in Chromium
// setTimeout(() => {
//   addResult('TEST TIMED OUT!');
//   completeTest();
// }, 6000);

/** @type {!Array<string>} */
let _results = [];

let _innerAddResult = text => {
  _results.push(String(text));
};

export function setInnerResult(updatedInnerResult) {
  _innerAddResult = updatedInnerResult;
}

/**
 * @param {*} text
 */
export function addResult(text) {
  _innerAddResult(text);
}

let completed = false;

let _innerCompleteTest = () => {
  if (completed) {
    return;
  }
  completed = true;
  flushResults();
  self.testRunner.notifyDone();
};

export function setInnerCompleteTest(updatedInnerCompleteTest) {
  _innerCompleteTest = updatedInnerCompleteTest;
}

export function completeTest() {
  _innerCompleteTest();
}

self.TestRunner = self.TestRunner || {};

/**
 * @suppressGlobalPropertiesCheck
 */
export function flushResults() {
  Array.prototype.forEach.call(document.documentElement.childNodes, x => x.remove());
  const outputElement = document.createElement('div');
  // Support for svg - add to document, not body, check for style.
  if (outputElement.style) {
    outputElement.style.whiteSpace = 'pre';
    outputElement.style.height = '10px';
    outputElement.style.overflow = 'hidden';
  }
  document.documentElement.appendChild(outputElement);
  for (let i = 0; i < _results.length; i++) {
    outputElement.appendChild(document.createTextNode(_results[i]));
    outputElement.appendChild(document.createElement('br'));
  }
  _results = [];
}

/**
 * @param {!Array<string>} textArray
 */
export function addResults(textArray) {
  if (!textArray) {
    return;
  }
  for (let i = 0, size = textArray.length; i < size; ++i) {
    addResult(textArray[i]);
  }
}

/**
 * @param {!Array<function()>} tests
 */
export function runTests(tests) {
  nextTest();

  function nextTest() {
    const test = tests.shift();
    if (!test) {
      completeTest();
      return;
    }
    addResult('\ntest: ' + test.name);
    let testPromise = test();
    if (!(testPromise instanceof Promise)) {
      testPromise = Promise.resolve();
    }
    testPromise.then(nextTest);
  }
}

/**
 * @param {!Object} receiver
 * @param {string} methodName
 * @param {!Function} override
 * @param {boolean=} opt_sticky
 */
export function addSniffer(receiver, methodName, override, opt_sticky) {
  override = safeWrap(override);

  const original = receiver[methodName];
  if (typeof original !== 'function') {
    throw new Error('Cannot find method to override: ' + methodName);
  }

  receiver[methodName] = function(var_args) {
    let result;
    try {
      result = original.apply(this, arguments);
    } finally {
      if (!opt_sticky) {
        receiver[methodName] = original;
      }
    }
    // In case of exception the override won't be called.
    try {
      Array.prototype.push.call(arguments, result);
      override.apply(this, arguments);
    } catch (e) {
      throw new Error('Exception in overriden method \'' + methodName + '\': ' + e);
    }
    return result;
  };
}

/**
 * @param {!Object} receiver
 * @param {string} methodName
 * @return {!Promise<*>}
 */
export function addSnifferPromise(receiver, methodName) {
  return new Promise(function(resolve, reject) {
    const original = receiver[methodName];
    if (typeof original !== 'function') {
      reject('Cannot find method to override: ' + methodName);
      return;
    }

    receiver[methodName] = function(var_args) {
      let result;
      try {
        result = original.apply(this, arguments);
      } finally {
        receiver[methodName] = original;
      }
      // In case of exception the override won't be called.
      try {
        Array.prototype.push.call(arguments, result);
        resolve.apply(this, arguments);
      } catch (e) {
        reject('Exception in overridden method \'' + methodName + '\': ' + e);
        completeTest();
      }
      return result;
    };
  });
}

/** @type {function():void} */
let _resolveOnFinishInits;

/**
 * @param {string} module
 * @return {!Promise<undefined>}
 */
export async function loadModule(module) {
  const promise = new Promise(resolve => _resolveOnFinishInits = resolve);
  await self.runtime.loadModulePromise(module);
  if (!_pendingInits) {
    return;
  }
  return promise;
}

/**
 * @param {string} panel
 * @return {!Promise.<?UI.Panel>}
 */
export function showPanel(panel) {
  return self.UI.viewManager.showView(panel);
}

/**
 * @param {string} key
 * @param {boolean=} ctrlKey
 * @param {boolean=} altKey
 * @param {boolean=} shiftKey
 * @param {boolean=} metaKey
 * @return {!KeyboardEvent}
 */
export function createKeyEvent(key, ctrlKey, altKey, shiftKey, metaKey) {
  return new KeyboardEvent('keydown', {
    key: key,
    bubbles: true,
    cancelable: true,
    ctrlKey: !!ctrlKey,
    altKey: !!altKey,
    shiftKey: !!shiftKey,
    metaKey: !!metaKey
  });
}

/**
 * Wraps a test function with an exception filter. Does not work
 * correctly for async functions; use safeAsyncWrap instead.
 * @param {!Function|undefined} func
 * @param {!Function=} onexception
 * @return {!Function}
 */
export function safeWrap(func, onexception) {
  /**
   * @this {*}
   */
  function result() {
    if (!func) {
      return;
    }
    const wrapThis = this;
    try {
      return func.apply(wrapThis, arguments);
    } catch (e) {
      addResult('Exception while running: ' + func + '\n' + (e.stack || e));
      if (onexception) {
        safeWrap(onexception)();
      } else {
        completeTest();
      }
    }
  }
  return result;
}

/**
 * Wraps a test function that returns a Promise with an exception
 * filter. Does not work correctly for functions which don't return
 * a Promise; use safeWrap instead.
 * @param {function(...):Promise<*>} func
 * @return {function(...):Promise<*>}
 */
export function safeAsyncWrap(func) {
  /**
   * @this {*}
   */
  async function result() {
    if (!func) {
      return;
    }
    const wrapThis = this;
    try {
      return await func.apply(wrapThis, arguments);
    } catch (e) {
      addResult('Exception while running: ' + func + '\n' + (e.stack || e));
      completeTest();
    }
  }
  return result;
}

/**
 * @param {!Node} node
 * @return {string}
 */
export function textContentWithLineBreaks(node) {
  function padding(currentNode) {
    let result = 0;
    while (currentNode && currentNode !== node) {
      if (currentNode.nodeName === 'OL' &&
          !(currentNode.classList && currentNode.classList.contains('object-properties-section'))) {
        ++result;
      }
      currentNode = currentNode.parentNode;
    }
    return Array(result * 4 + 1).join(' ');
  }

  let buffer = '';
  let currentNode = node;
  let ignoreFirst = false;
  while (currentNode.traverseNextNode(node)) {
    currentNode = currentNode.traverseNextNode(node);
    if (currentNode.nodeType === Node.TEXT_NODE) {
      buffer += currentNode.nodeValue;
    } else if (currentNode.nodeName === 'LI' || currentNode.nodeName === 'TR') {
      if (!ignoreFirst) {
        buffer += '\n' + padding(currentNode);
      } else {
        ignoreFirst = false;
      }
    } else if (currentNode.nodeName === 'STYLE') {
      currentNode = currentNode.traverseNextNode(node);
      continue;
    } else if (currentNode.classList && currentNode.classList.contains('object-properties-section')) {
      ignoreFirst = true;
    }
  }
  return buffer;
}

/**
 * @param {!Node} node
 * @return {string}
 */
export function textContentWithoutStyles(node) {
  let buffer = '';
  let currentNode = node;
  while (currentNode.traverseNextNode(node)) {
    currentNode = currentNode.traverseNextNode(node);
    if (currentNode.nodeType === Node.TEXT_NODE) {
      buffer += currentNode.nodeValue;
    } else if (currentNode.nodeName === 'STYLE') {
      currentNode = currentNode.traverseNextNode(node);
    }
  }
  return buffer;
}

/**
 * @param {string} code
 * @return {!Promise<*>}
 */
export async function evaluateInPageRemoteObject(code) {
  const response = await _evaluateInPage(code);
  return TestRunner.runtimeModel.createRemoteObject(response.result);
}

/**
 * @param {string} code
 * @param {function(*, !Protocol.Runtime.ExceptionDetails=):void} callback
 */
export async function evaluateInPage(code, callback) {
  const response = await _evaluateInPage(code);
  safeWrap(callback)(response.result.value, response.exceptionDetails);
}

/** @type {number} */
let _evaluateInPageCounter = 0;

/**
 * @param {string} code
 * @return {!Promise<undefined|{response: (!SDK.RemoteObject|undefined),
 *   exceptionDetails: (!Protocol.Runtime.ExceptionDetails|undefined)}>}
 */
export async function _evaluateInPage(code) {
  const lines = new Error().stack.split('at ');

  // Handles cases where the function is safe wrapped
  const testScriptURL = /** @type {string} */ (Root.Runtime.queryParam('test'));
  const functionLine = lines.reduce((acc, line) => line.includes(testScriptURL) ? line : acc, lines[lines.length - 2]);

  const components = functionLine.trim().split('/');
  const source = components[components.length - 1].slice(0, -1).split(':');
  const fileName = source[0];
  const sourceURL = `test://evaluations/${_evaluateInPageCounter++}/` + fileName;
  const lineOffset = parseInt(source[1], 10);
  code = '\n'.repeat(lineOffset - 1) + code;
  if (code.indexOf('sourceURL=') === -1) {
    code += `//# sourceURL=${sourceURL}`;
  }
  const response = await TestRunner.RuntimeAgent.invoke_evaluate({expression: code, objectGroup: 'console'});
  const error = response[ProtocolClientModule.InspectorBackend.ProtocolError];
  if (error) {
    addResult('Error: ' + error);
    completeTest();
    return;
  }
  return response;
}

/**
 * Doesn't append sourceURL to snippets evaluated in inspected page
 * to avoid churning test expectations
 * @param {string} code
 * @param {boolean=} userGesture
 * @return {!Promise<*>}
 */
export async function evaluateInPageAnonymously(code, userGesture) {
  const response =
      await TestRunner.RuntimeAgent.invoke_evaluate({expression: code, objectGroup: 'console', userGesture});
  if (!response[ProtocolClientModule.InspectorBackend.ProtocolError]) {
    return response.result.value;
  }
  addResult(
      'Error: ' +
      (response.exceptionDetails && response.exceptionDetails.text || 'exception from evaluateInPageAnonymously.'));
  completeTest();
}

/**
 * @param {string} code
 * @return {!Promise<*>}
 */
export function evaluateInPagePromise(code) {
  return new Promise(success => evaluateInPage(code, success));
}

/**
 * @param {string} code
 * @return {!Promise<*>}
 */
export async function evaluateInPageAsync(code) {
  const response = await TestRunner.RuntimeAgent.invoke_evaluate(
      {expression: code, objectGroup: 'console', includeCommandLineAPI: false, awaitPromise: true});

  const error = response[ProtocolClientModule.InspectorBackend.ProtocolError];
  if (!error && !response.exceptionDetails) {
    return response.result.value;
  }
  addResult(
      'Error: ' +
      (error || response.exceptionDetails && response.exceptionDetails.text || 'exception while evaluation in page.'));
  completeTest();
}

/**
 * @param {string} name
 * @param {!Array<*>} args
 * @return {!Promise<*>}
 */
export function callFunctionInPageAsync(name, args) {
  args = args || [];
  return evaluateInPageAsync(name + '(' + args.map(a => JSON.stringify(a)).join(',') + ')');
}

/**
 * @param {string} code
 * @param {boolean=} userGesture
 */
export function evaluateInPageWithTimeout(code, userGesture) {
  // FIXME: we need a better way of waiting for chromium events to happen
  evaluateInPageAnonymously('setTimeout(unescape(\'' + escape(code) + '\'), 1)', userGesture);
}

/**
 * @param {function():*} func
 * @param {function(*):void} callback
 */
export function evaluateFunctionInOverlay(func, callback) {
  const expression = 'internals.evaluateInInspectorOverlay("(" + ' + func + ' + ")()")';
  const mainContext = TestRunner.runtimeModel.executionContexts()[0];
  mainContext
      .evaluate(
          {
            expression: expression,
            objectGroup: '',
            includeCommandLineAPI: false,
            silent: false,
            returnByValue: true,
            generatePreview: false
          },
          /* userGesture */ false, /* awaitPromise*/ false)
      .then(result => void callback(result.object.value));
}

/**
 * @param {boolean} passCondition
 * @param {string} failureText
 */
export function check(passCondition, failureText) {
  if (!passCondition) {
    addResult('FAIL: ' + failureText);
  }
}

/**
 * @param {!Function} callback
 */
export function deprecatedRunAfterPendingDispatches(callback) {
  ProtocolClient.test.deprecatedRunAfterPendingDispatches(callback);
}

/**
 * This ensures a base tag is set so all DOM references
 * are relative to the test file and not the inspected page
 * (i.e. http/tests/devtools/resources/inspected-page.html).
 * @param {string} html
 * @return {!Promise<*>}
 */
export function loadHTML(html) {
  if (!html.includes('<base')) {
    // <!DOCTYPE...> tag needs to be first
    const doctypeRegex = /(<!DOCTYPE.*?>)/i;
    const baseTag = `<base href="${url()}">`;
    if (html.match(doctypeRegex)) {
      html = html.replace(doctypeRegex, '$1' + baseTag);
    } else {
      html = baseTag + html;
    }
  }
  html = html.replace(/'/g, '\\\'').replace(/\n/g, '\\n');
  return evaluateInPageAnonymously(`document.write(\`${html}\`);document.close();`);
}

/**
 * @param {string} path
 * @return {!Promise<*>}
 */
export function addScriptTag(path) {
  return evaluateInPageAsync(`
    (function(){
      let script = document.createElement('script');
      script.src = '${path}';
      document.head.append(script);
      return new Promise(f => script.onload = f);
    })();
  `);
}

/**
 * @param {string} path
 * @return {!Promise<*>}
 */
export function addStylesheetTag(path) {
  return evaluateInPageAsync(`
    (function(){
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      link.href = '${path}';
      link.onload = onload;
      document.head.append(link);
      let resolve;
      const promise = new Promise(r => resolve = r);
      function onload() {
        // TODO(chenwilliam): It shouldn't be necessary to force
        // style recalc here but some tests rely on it.
        window.getComputedStyle(document.body).color;
        resolve();
      }
      return promise;
    })();
  `);
}

/**
 * @param {string} path
 * @return {!Promise<*>}
 */
export function addHTMLImport(path) {
  return evaluateInPageAsync(`
    (function(){
      const link = document.createElement('link');
      link.rel = 'import';
      link.href = '${path}';
      const promise = new Promise(r => link.onload = r);
      document.body.append(link);
      return promise;
    })();
  `);
}

/**
 * NOTE you should manually ensure the path is correct. There
 * is no error event triggered if it is incorrect, and this is
 * in line with the standard (crbug 365457).
 * @param {string} path
 * @param {!Object|undefined} options
 * @return {!Promise<*>}
 */
export function addIframe(path, options = {}) {
  options.id = options.id || '';
  options.name = options.name || '';
  return evaluateInPageAsync(`
    (function(){
      const iframe = document.createElement('iframe');
      iframe.src = '${path}';
      iframe.id = '${options.id}';
      iframe.name = '${options.name}';
      document.body.appendChild(iframe);
      return new Promise(f => iframe.onload = f);
    })();
  `);
}

/** @type {number} */
let _pendingInits = 0;

/**
 * The old test framework executed certain snippets in the inspected page
 * context as part of loading a test helper file.
 *
 * This is deprecated because:
 * 1) it makes the testing API less intuitive (need to read the various *TestRunner.js
 * files to know which helper functions are available in the inspected page).
 * 2) it complicates the test framework's module loading process.
 *
 * In most cases, this is used to set up inspected page functions (e.g. makeSimpleXHR)
 * which should become a *TestRunner method (e.g. NetworkTestRunner.makeSimpleXHR)
 * that calls evaluateInPageAnonymously(...).
 * @param {string} code
 */
export async function deprecatedInitAsync(code) {
  _pendingInits++;
  await TestRunner.RuntimeAgent.invoke_evaluate({expression: code, objectGroup: 'console'});
  _pendingInits--;
  if (!_pendingInits) {
    _resolveOnFinishInits();
  }
}

/**
 * @param {string} title
 */
export function markStep(title) {
  addResult('\nRunning: ' + title);
}

export function startDumpingProtocolMessages() {
  ProtocolClient.test.dumpProtocol = self.testRunner.logToStderr.bind(self.testRunner);
}

/**
 * @param {string} url
 * @param {string} content
 * @param {!SDK.ResourceTreeFrame} frame
 */
export function addScriptForFrame(url, content, frame) {
  content += '\n//# sourceURL=' + url;
  const executionContext = TestRunner.runtimeModel.executionContexts().find(context => context.frameId === frame.id);
  TestRunner.RuntimeAgent.evaluate(content, 'console', false, false, executionContext.id);
}

export const formatters = {


  /**
 * @param {*} value
 * @return {string}
 */
  formatAsTypeName(value) {
    return '<' + typeof value + '>';
  },

  /**
 * @param {*} value
 * @return {string}
 */
  formatAsTypeNameOrNull(value) {
    if (value === null) {
      return 'null';
    }
    return formatters.formatAsTypeName(value);
  },

  /**
 * @param {*} value
 * @return {string|!Date}
 */
  formatAsRecentTime(value) {
    if (typeof value !== 'object' || !(value instanceof Date)) {
      return formatters.formatAsTypeName(value);
    }
    const delta = Date.now() - value;
    return 0 <= delta && delta < 30 * 60 * 1000 ? '<plausible>' : value;
  },

  /**
 * @param {string} value
 * @return {string}
 */
  formatAsURL(value) {
    if (!value) {
      return value;
    }
    const lastIndex = value.lastIndexOf('devtools/');
    if (lastIndex < 0) {
      return value;
    }
    return '.../' + value.substr(lastIndex);
  },

  /**
 * @param {string} value
 * @return {string}
 */
  formatAsDescription(value) {
    if (!value) {
      return value;
    }
    return '"' + value.replace(/^function [gs]et /, 'function ') + '"';
  },
};

/**
 * @param {!Object} object
 * @param {!TestRunner.CustomFormatters=} customFormatters
 * @param {string=} prefix
 * @param {string=} firstLinePrefix
 */
export function addObject(object, customFormatters, prefix, firstLinePrefix) {
  prefix = prefix || '';
  firstLinePrefix = firstLinePrefix || prefix;
  addResult(firstLinePrefix + '{');
  const propertyNames = Object.keys(object);
  propertyNames.sort();
  for (let i = 0; i < propertyNames.length; ++i) {
    const prop = propertyNames[i];
    if (!object.hasOwnProperty(prop)) {
      continue;
    }
    const prefixWithName = '    ' + prefix + prop + ' : ';
    const propValue = object[prop];
    if (customFormatters && customFormatters[prop]) {
      const formatterName = customFormatters[prop];
      if (formatterName !== 'skip') {
        const formatter = formatters[formatterName];
        addResult(prefixWithName + formatter(propValue));
      }
    } else {
      dump(propValue, customFormatters, '    ' + prefix, prefixWithName);
    }
  }
  addResult(prefix + '}');
}

/**
 * @param {!Array} array
 * @param {!TestRunner.CustomFormatters=} customFormatters
 * @param {string=} prefix
 * @param {string=} firstLinePrefix
 */
export function addArray(array, customFormatters, prefix, firstLinePrefix) {
  prefix = prefix || '';
  firstLinePrefix = firstLinePrefix || prefix;
  addResult(firstLinePrefix + '[');
  for (let i = 0; i < array.length; ++i) {
    dump(array[i], customFormatters, prefix + '    ');
  }
  addResult(prefix + ']');
}

/**
 * @param {!Node} node
 */
export function dumpDeepInnerHTML(node) {
  /**
   * @param {string} prefix
   * @param {!Node} node
   */
  function innerHTML(prefix, node) {
    const openTag = [];
    if (node.nodeType === Node.TEXT_NODE) {
      if (!node.parentElement || node.parentElement.nodeName !== 'STYLE') {
        addResult(node.nodeValue);
      }
      return;
    }
    openTag.push('<' + node.nodeName);
    const attrs = node.attributes;
    for (let i = 0; attrs && i < attrs.length; ++i) {
      openTag.push(attrs[i].name + '=' + attrs[i].value);
    }

    openTag.push('>');
    addResult(prefix + openTag.join(' '));
    for (let child = node.firstChild; child; child = child.nextSibling) {
      innerHTML(prefix + '    ', child);
    }
    if (node.shadowRoot) {
      innerHTML(prefix + '    ', node.shadowRoot);
    }
    addResult(prefix + '</' + node.nodeName + '>');
  }
  innerHTML('', node);
}

/**
 * @param {!Node} node
 * @return {string}
 */
export function deepTextContent(node) {
  if (!node) {
    return '';
  }
  if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
    return !node.parentElement || node.parentElement.nodeName !== 'STYLE' ? node.nodeValue : '';
  }
  let res = '';
  const children = node.childNodes;
  for (let i = 0; i < children.length; ++i) {
    res += deepTextContent(children[i]);
  }
  if (node.shadowRoot) {
    res += deepTextContent(node.shadowRoot);
  }
  return res;
}

/**
 * @param {*} value
 * @param {!TestRunner.CustomFormatters=} customFormatters
 * @param {string=} prefix
 * @param {string=} prefixWithName
 */
export function dump(value, customFormatters, prefix, prefixWithName) {
  prefixWithName = prefixWithName || prefix;
  if (prefixWithName && prefixWithName.length > 80) {
    addResult(prefixWithName + 'was skipped due to prefix length limit');
    return;
  }
  if (value === null) {
    addResult(prefixWithName + 'null');
  } else if (value && value.constructor && value.constructor.name === 'Array') {
    addArray(/** @type {!Array} */ (value), customFormatters, prefix, prefixWithName);
  } else if (typeof value === 'object') {
    addObject(/** @type {!Object} */ (value), customFormatters, prefix, prefixWithName);
  } else if (typeof value === 'string') {
    addResult(prefixWithName + '"' + value + '"');
  } else {
    addResult(prefixWithName + value);
  }
}

/**
 * @param {!UI.TreeElement} treeElement
 */
export function dumpObjectPropertyTreeElement(treeElement) {
  const expandedSubstring = treeElement.expanded ? '[expanded]' : '[collapsed]';
  addResult(expandedSubstring + ' ' + treeElement.listItemElement.deepTextContent());

  for (let i = 0; i < treeElement.childCount(); ++i) {
    const property = treeElement.childAt(i).property;
    const key = property.name;
    const value = property.value._description;
    addResult('    ' + key + ': ' + value);
  }
}

/**
 * @param {symbol} eventName
 * @param {!Common.ObjectWrapper.ObjectWrapper} obj
 * @param {function(?):boolean=} condition
 * @return {!Promise}
 */
export function waitForEvent(eventName, obj, condition) {
  condition = condition || function() {
    return true;
  };
  return new Promise(resolve => {
    obj.addEventListener(eventName, onEventFired);

    /**
     * @param {!Common.EventTarget.EventTargetEvent} event
     */
    function onEventFired(event) {
      if (!condition(event.data)) {
        return;
      }
      obj.removeEventListener(eventName, onEventFired);
      resolve(event.data);
    }
  });
}

/**
 * @param {function(!SDK.Target):boolean} filter
 * @return {!Promise<!SDK.Target>}
 */
export function waitForTarget(filter) {
  filter = filter || (target => true);
  for (const target of self.SDK.targetManager.targets()) {
    if (filter(target)) {
      return Promise.resolve(target);
    }
  }
  return new Promise(fulfill => {
    const observer = /** @type {!SDK.TargetManager.Observer} */ ({
      targetAdded: function(target) {
        if (filter(target)) {
          self.SDK.targetManager.unobserveTargets(observer);
          fulfill(target);
        }
      },
      targetRemoved: function() {},
    });
    self.SDK.targetManager.observeTargets(observer);
  });
}

/**
 * @param {!SDK.Target} targetToRemove
 * @return {!Promise<!SDK.Target>}
 */
export function waitForTargetRemoved(targetToRemove) {
  return new Promise(fulfill => {
    const observer = /** @type {!SDK.TargetManager.Observer} */ ({
      targetRemoved: function(target) {
        if (target === targetToRemove) {
          self.SDK.targetManager.unobserveTargets(observer);
          fulfill(target);
        }
      },
      targetAdded: function() {},
    });
    self.SDK.targetManager.observeTargets(observer);
  });
}

/**
 * @param {!SDK.RuntimeModel} runtimeModel
 * @return {!Promise}
 */
export function waitForExecutionContext(runtimeModel) {
  if (runtimeModel.executionContexts().length) {
    return Promise.resolve(runtimeModel.executionContexts()[0]);
  }
  return runtimeModel.once(SDK.RuntimeModel.Events.ExecutionContextCreated);
}

/**
 * @param {!SDK.ExecutionContext} context
 * @return {!Promise}
 */
export function waitForExecutionContextDestroyed(context) {
  const runtimeModel = context.runtimeModel;
  if (runtimeModel.executionContexts().indexOf(context) === -1) {
    return Promise.resolve();
  }
  return waitForEvent(
      SDK.RuntimeModel.Events.ExecutionContextDestroyed, runtimeModel,
      destroyedContext => destroyedContext === context);
}

/**
 * @param {number} a
 * @param {number} b
 * @param {string=} message
 */
export function assertGreaterOrEqual(a, b, message) {
  if (a < b) {
    addResult('FAILED: ' + (message ? message + ': ' : '') + a + ' < ' + b);
  }
}

let _pageLoadedCallback;

/**
 * @param {string} url
 * @param {function():void} callback
 */
export function navigate(url, callback) {
  _pageLoadedCallback = safeWrap(callback);
  TestRunner.resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.Load, _pageNavigated);
  // Note: injected <base> means that url is relative to test
  // and not the inspected page
  evaluateInPageAnonymously('window.location.replace(\'' + url + '\')');
}

/**
 * @return {!Promise}
 */
export function navigatePromise(url) {
  return new Promise(fulfill => navigate(url, fulfill));
}

export function _pageNavigated() {
  TestRunner.resourceTreeModel.removeEventListener(SDK.ResourceTreeModel.Events.Load, _pageNavigated);
  _handlePageLoaded();
}

/**
 * @param {function():void} callback
 */
export function hardReloadPage(callback) {
  _innerReloadPage(true, undefined, callback);
}

/**
 * @param {function():void} callback
 */
export function reloadPage(callback) {
  _innerReloadPage(false, undefined, callback);
}

/**
 * @param {(string|undefined)} injectedScript
 * @param {function():void} callback
 */
export function reloadPageWithInjectedScript(injectedScript, callback) {
  _innerReloadPage(false, injectedScript, callback);
}

/**
 * @return {!Promise}
 */
export function reloadPagePromise() {
  return new Promise(fulfill => reloadPage(fulfill));
}

/**
 * @param {boolean} hardReload
 * @param {(string|undefined)} injectedScript
 * @param {function():void} callback
 */
export function _innerReloadPage(hardReload, injectedScript, callback) {
  _pageLoadedCallback = safeWrap(callback);
  TestRunner.resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.Load, pageLoaded);
  TestRunner.resourceTreeModel.reloadPage(hardReload, injectedScript);
}

export function pageLoaded() {
  TestRunner.resourceTreeModel.removeEventListener(SDK.ResourceTreeModel.Events.Load, pageLoaded);
  addResult('Page reloaded.');
  _handlePageLoaded();
}

export async function _handlePageLoaded() {
  await waitForExecutionContext(/** @type {!SDK.RuntimeModel} */ (TestRunner.runtimeModel));
  if (_pageLoadedCallback) {
    const callback = _pageLoadedCallback;
    _pageLoadedCallback = undefined;
    callback();
  }
}

/**
 * @param {function():void} callback
 */
export function waitForPageLoad(callback) {
  TestRunner.resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.Load, onLoaded);

  function onLoaded() {
    TestRunner.resourceTreeModel.removeEventListener(SDK.ResourceTreeModel.Events.Load, onLoaded);
    callback();
  }
}

/**
 * @param {function():void} callback
 */
export function runWhenPageLoads(callback) {
  const oldCallback = _pageLoadedCallback;
  function chainedCallback() {
    if (oldCallback) {
      oldCallback();
    }
    callback();
  }
  _pageLoadedCallback = safeWrap(chainedCallback);
}

/**
 * @param {!Array<function(function():void)>} testSuite
 */
export function runTestSuite(testSuite) {
  const testSuiteTests = testSuite.slice();

  function runner() {
    if (!testSuiteTests.length) {
      completeTest();
      return;
    }
    const nextTest = testSuiteTests.shift();
    addResult('');
    addResult(
        'Running: ' +
        /function\s([^(]*)/.exec(nextTest)[1]);
    safeWrap(nextTest)(runner);
  }
  runner();
}

/**
 * @param {!Array<function():Promise<*>>} testSuite
 */
export async function runAsyncTestSuite(testSuite) {
  for (const nextTest of testSuite) {
    addResult('');
    addResult(
        'Running: ' +
        /function\s([^(]*)/.exec(nextTest)[1]);
    await safeAsyncWrap(nextTest)();
  }

  completeTest();
}

/**
 * @param {*} expected
 * @param {*} found
 * @param {string} message
 */
export function assertEquals(expected, found, message) {
  if (expected === found) {
    return;
  }

  let error;
  if (message) {
    error = 'Failure (' + message + '):';
  } else {
    error = 'Failure:';
  }
  throw new Error(error + ' expected <' + expected + '> found <' + found + '>');
}

/**
 * @param {*} found
 * @param {string} message
 */
export function assertTrue(found, message) {
  assertEquals(true, !!found, message);
}

/**
 * @param {!Object} receiver
 * @param {string} methodName
 * @param {!Function} override
 * @param {boolean=} opt_sticky
 * @return {!Function}
 */
export function override(receiver, methodName, override, opt_sticky) {
  override = safeWrap(override);

  const original = receiver[methodName];
  if (typeof original !== 'function') {
    throw new Error('Cannot find method to override: ' + methodName);
  }

  receiver[methodName] = function(var_args) {
    try {
      return override.apply(this, arguments);
    } catch (e) {
      throw new Error('Exception in overriden method \'' + methodName + '\': ' + e);
    } finally {
      if (!opt_sticky) {
        receiver[methodName] = original;
      }
    }
  };

  return original;
}

/**
 * @param {string} text
 * @return {string}
 */
export function clearSpecificInfoFromStackFrames(text) {
  let buffer = text.replace(/\(file:\/\/\/(?:[^)]+\)|[\w\/:-]+)/g, '(...)');
  buffer = buffer.replace(/\(http:\/\/(?:[^)]+\)|[\w\/:-]+)/g, '(...)');
  buffer = buffer.replace(/\(test:\/\/(?:[^)]+\)|[\w\/:-]+)/g, '(...)');
  buffer = buffer.replace(/\(<anonymous>:[^)]+\)/g, '(...)');
  buffer = buffer.replace(/VM\d+/g, 'VM');
  return buffer.replace(/\s*at[^()]+\(native\)/g, '');
}

export function hideInspectorView() {
  self.UI.inspectorView.element.setAttribute('style', 'display:none !important');
}

/**
 * @return {?SDK.ResourceTreeFrame}
 */
export function mainFrame() {
  return TestRunner.resourceTreeModel.mainFrame;
}

export class StringOutputStream {
  /**
   * @param {function(string):void} callback
   */
  constructor(callback) {
    this._callback = callback;
    this._buffer = '';
  }

  /**
   * @param {string} fileName
   * @return {!Promise<boolean>}
   */
  async open(fileName) {
    return true;
  }

  /**
   * @param {string} chunk
   */
  async write(chunk) {
    this._buffer += chunk;
  }

  async close() {
    this._callback(this._buffer);
  }
}

/**
 * @template V
 */
export class MockSetting {
  /**
   * @param {V} value
   */
  constructor(value) {
    this._value = value;
  }

  /**
   * @return {V}
   */
  get() {
    return this._value;
  }

  /**
   * @param {V} value
   */
  set(value) {
    this._value = value;
  }
}

/**
 * @return {!Array<!Root.Runtime.Module>}
 */
export function loadedModules() {
  return self.runtime._modules.filter(module => module._loadedForTest)
      .filter(module => module.name() !== 'help')
      .filter(module => module.name().indexOf('test_runner') === -1);
}

/**
 * @param {!Array<!Root.Runtime.Module>} relativeTo
 * @return {!Array<!Root.Runtime.Module>}
 */
export function dumpLoadedModules(relativeTo) {
  const previous = new Set(relativeTo || []);
  function moduleSorter(left, right) {
    return String.naturalOrderComparator(left._descriptor.name, right._descriptor.name);
  }

  addResult('Loaded modules:');
  const sortedLoadedModules = loadedModules().sort(moduleSorter);
  for (const module of sortedLoadedModules) {
    if (previous.has(module)) {
      continue;
    }
    addResult('    ' + module._descriptor.name);
  }
  return sortedLoadedModules;
}

/**
 * @param {string} urlSuffix
 * @param {!Workspace.Workspace.projectTypes=} projectType
 * @return {!Promise}
 */
export function waitForUISourceCode(urlSuffix, projectType) {
  /**
   * @param {!Workspace.UISourceCode.UISourceCode} uiSourceCode
   * @return {boolean}
   */
  function matches(uiSourceCode) {
    if (projectType && uiSourceCode.project().type() !== projectType) {
      return false;
    }
    if (!projectType && uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Service) {
      return false;
    }
    if (urlSuffix && !uiSourceCode.url().endsWith(urlSuffix)) {
      return false;
    }
    return true;
  }

  for (const uiSourceCode of self.Workspace.workspace.uiSourceCodes()) {
    if (urlSuffix && matches(uiSourceCode)) {
      return Promise.resolve(uiSourceCode);
    }
  }

  return waitForEvent(Workspace.Workspace.Events.UISourceCodeAdded, self.Workspace.workspace, matches);
}

/**
 * @param {!Function} callback
 */
export function waitForUISourceCodeRemoved(callback) {
  self.Workspace.workspace.once(Workspace.Workspace.Events.UISourceCodeRemoved).then(callback);
}

/**
 * @param {string=} url
 * @return {string}
 */
export function url(url = '') {
  const testScriptURL = /** @type {string} */ (Root.Runtime.queryParam('test'));

  // This handles relative (e.g. "../file"), root (e.g. "/resource"),
  // absolute (e.g. "http://", "data:") and empty (e.g. "") paths
  return new URL(url, testScriptURL + '/../').href;
}

/**
 * @param {string} str
 * @param {string} mimeType
 * @return {!Promise.<undefined>}
 * @suppressGlobalPropertiesCheck
 */
export function dumpSyntaxHighlight(str, mimeType) {
  const node = document.createElement('span');
  node.textContent = str;
  const javascriptSyntaxHighlighter = new UI.SyntaxHighlighter(mimeType, false);
  return javascriptSyntaxHighlighter.syntaxHighlightNode(node).then(dumpSyntax);

  function dumpSyntax() {
    const node_parts = [];

    for (let i = 0; i < node.childNodes.length; i++) {
      if (node.childNodes[i].getAttribute) {
        node_parts.push(node.childNodes[i].getAttribute('class'));
      } else {
        node_parts.push('*');
      }
    }

    addResult(str + ': ' + node_parts.join(', '));
  }
}

/* this code exists in Platform.StringUtilities but these layout tests
* cannot import ES modules so we copy the required code in directly as
* these layout tests are going to be removed in favour of e2e so it's
* not worth adding ESM support here
*/

/**
 *
 * @param {string} inputString
 * @param {string} searchString
 * @return {!Array.<number>}
 */
const findIndexesOfSubString = function(inputString, searchString) {
  const matches = [];
  let i = inputString.indexOf(searchString);
  while (i !== -1) {
    matches.push(i);
    i = inputString.indexOf(searchString, i + searchString.length);
  }
  return matches;
};

/**
 *
 * @param {string} inputString
 * @return {!Array.<number>}
 */
export const findLineEndingIndexes = function(inputString) {
  const endings = findIndexesOfSubString(inputString, '\n');
  endings.push(inputString.length);
  return endings;
};

/**
 * @param {string} querySelector
 */
export async function dumpInspectedPageElementText(querySelector) {
  const value = await evaluateInPageAsync(`document.querySelector('${querySelector}').innerText`);
  addResult(value);
}

/**
 * This method blocks until all currently queued live location update handlers are done.
 *
 * Creating and updating live locations causes the update handler of each live location
 * to run. These update handlers are potentially asynchronous and usually cause re-rendering or
 * UI updates. Web tests then check for these updates.
 * To give tests more control, waitForPendingLiveLocationUpdates returns a promise that resolves
 * once all currently-pending updates (at call time) are completed.
 */
export async function waitForPendingLiveLocationUpdates() {
  await self.Bindings.debuggerWorkspaceBinding.pendingLiveLocationChangesPromise();
  await self.Bindings.cssWorkspaceBinding.pendingLiveLocationChangesPromise();
}

/** @type {!{logToStderr: function(), navigateSecondaryWindow: function(string), notifyDone: function()}|undefined} */
self.testRunner;

TestRunner.StringOutputStream = StringOutputStream;
TestRunner.MockSetting = MockSetting;

TestRunner.formatters = formatters;

TestRunner.flushResults = flushResults;
TestRunner.completeTest = completeTest;
TestRunner.addResult = addResult;
TestRunner.addResults = addResults;
TestRunner.runTests = runTests;
TestRunner.addSniffer = addSniffer;
TestRunner.addSnifferPromise = addSnifferPromise;
TestRunner.showPanel = showPanel;
TestRunner.createKeyEvent = createKeyEvent;
TestRunner.safeWrap = safeWrap;
TestRunner.safeAsyncWrap = safeAsyncWrap;
TestRunner.textContentWithLineBreaks = textContentWithLineBreaks;
TestRunner.textContentWithoutStyles = textContentWithoutStyles;
TestRunner.evaluateInPagePromise = evaluateInPagePromise;
TestRunner.callFunctionInPageAsync = callFunctionInPageAsync;
TestRunner.evaluateInPageWithTimeout = evaluateInPageWithTimeout;
TestRunner.evaluateFunctionInOverlay = evaluateFunctionInOverlay;
TestRunner.check = check;
TestRunner.deprecatedRunAfterPendingDispatches = deprecatedRunAfterPendingDispatches;
TestRunner.loadHTML = loadHTML;
TestRunner.addScriptTag = addScriptTag;
TestRunner.addStylesheetTag = addStylesheetTag;
TestRunner.addHTMLImport = addHTMLImport;
TestRunner.addIframe = addIframe;
TestRunner.markStep = markStep;
TestRunner.startDumpingProtocolMessages = startDumpingProtocolMessages;
TestRunner.addScriptForFrame = addScriptForFrame;
TestRunner.addObject = addObject;
TestRunner.addArray = addArray;
TestRunner.dumpDeepInnerHTML = dumpDeepInnerHTML;
TestRunner.deepTextContent = deepTextContent;
TestRunner.dump = dump;
TestRunner.dumpObjectPropertyTreeElement = dumpObjectPropertyTreeElement;
TestRunner.waitForEvent = waitForEvent;
TestRunner.waitForTarget = waitForTarget;
TestRunner.waitForTargetRemoved = waitForTargetRemoved;
TestRunner.waitForExecutionContext = waitForExecutionContext;
TestRunner.waitForExecutionContextDestroyed = waitForExecutionContextDestroyed;
TestRunner.assertGreaterOrEqual = assertGreaterOrEqual;
TestRunner.navigate = navigate;
TestRunner.navigatePromise = navigatePromise;
TestRunner.hardReloadPage = hardReloadPage;
TestRunner.reloadPage = reloadPage;
TestRunner.reloadPageWithInjectedScript = reloadPageWithInjectedScript;
TestRunner.reloadPagePromise = reloadPagePromise;
TestRunner.pageLoaded = pageLoaded;
TestRunner.waitForPageLoad = waitForPageLoad;
TestRunner.runWhenPageLoads = runWhenPageLoads;
TestRunner.runTestSuite = runTestSuite;
TestRunner.assertEquals = assertEquals;
TestRunner.assertTrue = assertTrue;
TestRunner.override = override;
TestRunner.clearSpecificInfoFromStackFrames = clearSpecificInfoFromStackFrames;
TestRunner.hideInspectorView = hideInspectorView;
TestRunner.mainFrame = mainFrame;
TestRunner.loadedModules = loadedModules;
TestRunner.dumpLoadedModules = dumpLoadedModules;
TestRunner.waitForUISourceCode = waitForUISourceCode;
TestRunner.waitForUISourceCodeRemoved = waitForUISourceCodeRemoved;
TestRunner.url = url;
TestRunner.dumpSyntaxHighlight = dumpSyntaxHighlight;
TestRunner.loadModule = loadModule;
TestRunner.evaluateInPageRemoteObject = evaluateInPageRemoteObject;
TestRunner.evaluateInPage = evaluateInPage;
TestRunner.evaluateInPageAnonymously = evaluateInPageAnonymously;
TestRunner.evaluateInPageAsync = evaluateInPageAsync;
TestRunner.deprecatedInitAsync = deprecatedInitAsync;
TestRunner.runAsyncTestSuite = runAsyncTestSuite;
TestRunner.dumpInspectedPageElementText = dumpInspectedPageElementText;
TestRunner.waitForPendingLiveLocationUpdates = waitForPendingLiveLocationUpdates;
TestRunner.findLineEndingIndexes = findLineEndingIndexes;

/**
 * @typedef {!Object<string, string>}
 */
TestRunner.CustomFormatters;
